문단 클릭 = 시작 위치 선택 · 재생은 우하단 버튼/P (재생 중 클릭 = 점프)

2장. 검색 증강 생성 기초와 실습

출처 — 브라이스 유·조경아·박수진·김재웅, 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉, 2025), 2장 (pp. 103~170). 원문 PDF rag_master_final_v11_260910.pdf (2026-09-10 판)

문서를 벡터로 바꾸고 저장해 두었다가, 질문이 들어오면 가장 관련 있는 조각을 찾아 답변에 근거로 쓰는 것 — 이것이 검색 증강 생성(RAG)의 전부다. 이 장은 그 다섯 조각(임베딩·문서 로더·텍스트 분할·벡터 DB·챗봇)을 하나씩 실습하며 이어 붙인다.

학습 목표

이 장을 끝내면 다음을 할 수 있다.

  • 텍스트 임베딩과 코사인 유사도의 관계를 설명하고, 두 벡터의 유사도를 직접 계산한다.
  • 웹 페이지·PDF·CSV 문서를 각각 알맞은 랭체인 문서 로더로 불러온다.
  • 재귀적 문자 텍스트 분할과 시맨틱 청킹의 차이를 구분하고, 문서 특성에 맞는 분할기를 선택한다.
  • 크로마와 파이스 중 상황에 맞는 벡터 데이터베이스를 고르고, 임베딩을 저장·검색한다.
  • 문서 로더·분할기·벡터 DB·검색기를 엮어 대화 기록을 기억하는 RAG 챗봇을 구현하고, 스트림릿으로 UI를 붙인다.

전체 흐름도

[ 문서 준비 ]  웹 페이지 · PDF · CSV
      │  문서 로더(Document Loader)로 표준 문서 객체(content+metadata)로 변환
      ▼
[ 텍스트 분할 ]  청크(chunk) 단위로 자르기
      │  재귀적 문자 텍스트 분할 (길이 기준) / 시맨틱 청킹 (의미 기준)
      ▼
[ 텍스트 임베딩 ]  청크 → 숫자 벡터
      │  오픈AI 임베딩 API / 허깅페이스 임베딩 모델(bge-m3 등)
      ▼
[ 벡터 데이터베이스 저장 ]  ── 인덱싱 과정 끝 ──
      │  크로마 · 파이스 등에 (벡터, 원문, 메타데이터) 저장
      ▼
[ 사용자 질문 입력 ]
      │  질문도 같은 임베딩 모델로 벡터화
      ▼
[ 검색기(Retriever) ]
      │  코사인 유사도로 관련 청크 상위 k개 검색  ── 쿼리 과정 시작 ──
      ▼
[ 프롬프트 구성 ]
      │  검색된 문맥 + 대화 기록 + 질문 → 프롬프트 템플릿
      ▼
[ LLM 호출 및 답변 생성 ]
      │  체인(chain)이 프롬프트 → 모델 → 출력 파서 순으로 실행
      ▼
[ 스트림릿 UI ]  사용자에게 대화형으로 노출

0. 용어 사전

참고 — 위쪽 5개는 이 장을 읽기 전에 알아야 하는 선행 용어다. 모두 1장(랭체인 기초)과 0장(실습 환경)에서 다뤘고, 이 장은 정의 없이 그대로 사용한다. 낯설면 0장·1장을 먼저 보라.

한글 용어 원문 영문명 의미
랭체인 LangChain (선행) LLM 기반 애플리케이션을 만드는 데 필요한 컴포넌트(모델·프롬프트·메모리·문서 로더·벡터 DB 등)를 표준화된 인터페이스로 묶어 둔 프레임워크. 이 장의 모든 도구(문서 로더·분할기·벡터 DB)가 이 프레임워크 위에서 동작한다. 부품을 하나의 표준 규격 소켓에 꽂아 쓰는 것에 비유. 1장 §1
러너블(LCEL) Runnable(LangChain Expression Language) (선행) \| 연산자로 여러 컴포넌트를 파이프처럼 이어 붙이는 랭체인의 조립 방식. 이 장 5절의 RunnablePassthrough·RunnableWithMessageHistory가 이 방식으로 체인을 구성한다. 컨베이어 벨트에 공정을 순서대로 연결하는 것에 비유. 1장 §3
프롬프트 템플릿 Prompt Template(ChatPromptTemplate) (선행) 역할(system·human 등)과 변수 자리({context}·{question})를 미리 정해 둔 프롬프트 틀. 이 장 5.1절의 챗봇이 검색 결과를 채워 넣는 틀로 그대로 쓴다. 1장 §4
출력 파서 Output Parser(StrOutputParser) (선행) 모델이 반환한 객체에서 필요한 부분(여기서는 문자열)만 뽑아내는 컴포넌트. 이 장 5.1절 체인의 마지막 단계로 쓰인다. 1장 §5
오픈AI API 키 OpenAI API Key (선행) 오픈AI 서비스(임베딩·챗 모델)를 호출하기 위한 인증 값. .env 파일에 저장해 load_dotenv()로 불러오는 방식을 이 장 전체에서 그대로 쓴다. 0장 §4·§5
검색 증강 생성(RAG) Retrieval-Augmented Generation 질문과 관련된 문서를 검색한 뒤, 그 결과를 근거로 답변을 생성하는 방식. 모델이 학습 시점 이후의 정보나 특정 문서의 내용을 몰라도 최신·전문 정보로 답할 수 있게 한다. 본문 §1
인덱싱 과정 Indexing Process 문서를 로드·분할·임베딩해 벡터 데이터베이스에 미리 저장해 두는 준비 단계. 챗봇 실행 전에 한 번 완료해 둔다. 본문 §1·§5
쿼리 과정 Query Process 사용자 질문이 들어온 뒤 그 질문을 벡터로 바꾸고, 관련 문서를 검색하고, 답변을 생성하는 실행 단계. 본문 §1·§5
임베딩 Embedding 텍스트(단어·문장·문서)를 의미를 담은 실수 벡터로 바꾸는 것. [1, 0.3, 0.5, 2]처럼 숫자로 표현하면 컴퓨터가 텍스트의 의미를 계산으로 비교할 수 있게 된다. 사람이 글을 읽어 이해하는 대신, 모델은 이 좌표를 비교해 "이해"한다고 볼 수 있다. 본문 §1.1
워드 임베딩 Word Embedding 단어 하나를 벡터로 바꾼 것. 본문 §1.1
문장 임베딩·문서 임베딩 Sentence/Document Embedding 문장 전체 또는 문서 전체를 하나의 벡터로 바꾼 것. 이 장의 실습은 대부분 문장·청크 단위 임베딩을 다룬다. 본문 §1.1
코사인 유사도 Cosine Similarity 두 벡터 사이의 각도로 유사성을 재는 척도. -1(정반대)~1(완전히 같은 방향) 사이 값을 가지며, 벡터의 크기(길이)가 아니라 방향만 본다. 두 화살표가 같은 방향을 가리키는지만 보고, 화살표 길이는 신경 쓰지 않는 것에 비유. 본문 §1.2
코사인 거리 Cosine Distance 코사인 유사도를 1 - 유사도로 뒤집어, 값이 작을수록 가깝다는 뜻으로 바꾼 것. 시맨틱 청킹이 분할 지점을 정할 때 이 값을 쓴다. 본문 §3.2
문서 로더 Document Loader 웹 페이지·PDF·CSV 등 다양한 형식의 데이터를 content(본문)와 metadata(출처 등)를 가진 표준 문서 객체로 바꾸는 도구. 본문 §2
청크 Chunk 긴 문서를 검색·처리하기 좋은 크기로 잘라 낸 조각. 벡터 DB에는 문서 전체가 아니라 청크 단위로 저장된다. 본문 §2·§3
텍스트 분할기 Text Splitter 긴 문서를 청크로 나누는 도구. 자르는 기준(길이·의미)에 따라 여러 종류가 있다. 본문 §3
재귀적 문자 텍스트 분할기 RecursiveCharacterTextSplitter 지정한 구분자(\n\n\n→``) 순서로, 청크가 지정 길이를 넘지 않을 때까지 반복해서 나누는 분할기. 랭체인에서 가장 널리 쓰인다. 본문 §3.1
시맨틱 청킹 SemanticChunker 문장 간 코사인 거리(의미 차이)가 크게 벌어지는 지점을 찾아 그 지점에서 나누는 분할기. 길이가 아니라 의미 전환을 기준으로 삼는다. 본문 §3.2
벡터 데이터베이스 Vector Database 임베딩 벡터를 대량으로 저장하고, 유사도 검색을 빠르게 수행하도록 설계된 저장소. 본문 §4
크로마 Chroma 오픈소스 벡터 데이터베이스. 설정이 간단해 빠른 프로토타이핑에 적합하고, persist_directory를 지정하면 디스크에 영구 저장할 수 있다. 본문 §4.1
파이스 FAISS(Facebook AI Similarity Search) 메타(옛 페이스북) AI 리서치가 만든 고성능 벡터 검색 라이브러리. 대규모 데이터셋에서 GPU 가속으로 빠른 유사도 검색을 제공한다. 본문 §4.2
검색기 Retriever 벡터 저장소에서 질문과 유사한 문서를 찾아 반환하는 컴포넌트. vectorstore.as_retriever()로 만들며, 챗봇 체인 안에서 자동 호출된다. 본문 §1·§5
스트림릿 Streamlit 파이썬 코드만으로 대화형 웹 UI를 만들 수 있는 라이브러리. HTML·CSS 지식 없이 챗봇 화면을 구성할 때 쓴다. 본문 §5.2
파이엔그록 pyngrok 로컬에서 실행 중인 서버(예: 스트림릿 앱)를 외부에서 접근 가능한 임시 URL로 열어 주는 터널링 도구. 구글 코랩에서 스트림릿 앱을 공유할 때 쓴다. 본문 §5.2

1. 검색 증강 생성 개요

검색 증강 생성(RAG)은 사용자의 질문과 관련된 문서를 먼저 검색하고, 그 결과를 근거로 답변을 생성하는 방식이다. 대규모 언어 모델은 학습 시점에 고정된 지식만 갖고 있어 최신 정보나 특정 조직의 내부 문서를 반영하지 못하는데, RAG는 검색기를 통해 그때그때 필요한 문서를 모델에 함께 건네줌으로써 이 한계를 메운다. 모델 가중치를 바꾸는 파인튜닝과 달리, RAG는 검색된 정보를 실시간으로 프롬프트에 실어 보내므로 비용이 적고 정보의 출처를 추적하기 쉽다.

RAG는 크게 인덱싱 과정쿼리 과정 두 단계로 나뉜다. 인덱싱 과정은 문서를 불러오고, 적절한 크기로 분할하고, 임베딩해 벡터 데이터베이스에 저장하는 준비 단계다. 쿼리 과정은 사용자 질문이 들어온 뒤 그 질문을 벡터로 바꿔 검색기로 관련 문서를 찾고, 그 결과를 바탕으로 답변을 생성하는 실행 단계다. 이 장은 이 두 과정을 구성하는 요소를 하나씩 실습하고, 마지막 5절에서 전체를 하나의 챗봇으로 합친다.

1.1 텍스트 임베딩

인공지능 모델은 텍스트를 사람처럼 읽어서 이해하지 않는다. 대신 텍스트를 실수로 이루어진 벡터로 바꾸어 그 벡터끼리 계산을 수행한다. 이 변환 과정을 임베딩이라 부른다. 단어 하나를 벡터로 바꾸면 워드 임베딩, 문장이나 문서 전체를 하나의 벡터로 바꾸면 문장 임베딩·문서 임베딩이라 한다.

임베딩된 벡터는 의미가 가까운 텍스트일수록 벡터 공간에서 가까운 위치에 놓인다. 이 성질 덕분에 챗봇은 사용자 질문과 의미적으로 가까운 문서를 빠르게 찾아낼 수 있다.

참고 — 임베딩은 이 장 전체의 토대다. 문서 로더가 불러온 문서도, 텍스트 분할기가 나눈 청크도, 결국 벡터 데이터베이스에 들어가기 전에는 모두 이 임베딩 변환을 거친다.

1.2 코사인 유사도

코사인 유사도는 두 벡터 사이의 각도로 유사성을 측정하는 방법이다. 벡터의 크기(길이)가 아니라 방향에만 주목하며, 값은 -1에서 1 사이다.

  • 1: 두 벡터가 완전히 같은 방향
  • 0: 두 벡터가 서로 직각
  • -1: 두 벡터가 정반대 방향

두 벡터 A, B의 코사인 유사도는 similarity = cos(θ) = (A·B) / (‖A‖ ‖B‖)로 계산한다. 여기서 A·B는 두 벡터의 내적, ‖A‖‖B‖는 각각 벡터의 크기(놈)다. 방향성만 보므로 문서 길이나 벡터 크기에 상관없이 의미적 유사성을 잴 수 있어, 텍스트 분석·문서 비교·추천 시스템에 두루 쓰인다.

# 라이브러리 불러오기
import numpy as np
from numpy import dot
from numpy.linalg import norm

# 코사인 유사도를 계산하는 함수 정의
def cos_sim(A, B):
    return dot(A, B) / (norm(A) * norm(B))

# 벡터들 간의 코사인 유사도 출력
vec1 = np.array([0, 1, 1, 1])
vec2 = np.array([1, 0, 2, 1])
vec3 = np.array([2, 0, 4, 2])
print('벡터1과 벡터2의 유사도:', cos_sim(vec1, vec2))
print('벡터1과 벡터3의 유사도:', cos_sim(vec1, vec3))
print('벡터2와 벡터3의 유사도:', cos_sim(vec2, vec3))

출력:

벡터1과 벡터2의 유사도: 0.7071067811865476
벡터1과 벡터3의 유사도: 0.7071067811865476
벡터2와 벡터3의 유사도: 1.0000000000000002

vec3vec2를 정확히 2배 늘린 벡터라 방향이 완전히 같고, 유사도가 최댓값인 1.0으로 나온다. 벡터1은 나머지 둘과 방향이 달라 유사도가 상대적으로 낮다. 이렇게 임베딩과 코사인 유사도를 합치면, 사용자 질문 벡터와 문서 벡터 사이의 유사도를 계산해 가장 관련성 높은 문서를 찾는 검색 메커니즘을 만들 수 있다 — 이것이 이 장 5절 RAG 챗봇의 핵심 동작이다.

1.3 랭체인 임베딩 API 활용

랭체인이 제공하는 임베딩 모델은 크게 두 유형으로 나뉜다.

LLM 제공사의 임베딩 모델 — 오픈AI의 text-embedding-ada-002·text-embedding-3-small·text-embedding-3-large가 대표적이며, 코히어·미스트랄AI 등도 자체 임베딩 모델을 제공한다. 다른 제공사의 임베딩 모델은 랭체인 텍스트 임베딩 통합 목록에서 확인할 수 있다(원문은 python.langchain.com/docs/integrations/text_embedding/을 실었으나 현재는 위 주소로 이전됐다).

# 라이브러리 불러오기
import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings

# .env 파일에서 환경 변수 로드
load_dotenv("/content/.env")
api_key = os.getenv("OPENAI_API_KEY")

# 임베딩 모델 객체 생성 후 문장을 벡터로 변환
embeddings = OpenAIEmbeddings(model="text-embedding-ada-002")
query_result = embeddings.embed_query('저는 배가 고파요')
print(query_result)

출력(일부 생략):

[-0.016639344283517023, -0.021817782168952537, 0.015167301584591451, ...(중간 생략)..., 0.01335353432182433]

"저는 배가 고파요"라는 문장이 1,536개의 실수로 구성된 벡터로 변환됐다. 이제 여러 문장을 담은 데이터프레임을 만들고, 임의의 질문과 가장 유사한 문장을 찾아보자.

import pandas as pd

# 예시 문장 데이터셋
data = [
    '주식 시장이 급등했어요',
    '시장 물가가 올랐어요',
    '전통 시장에는 다양한 물품들을 팔아요',
    '부동산 시장이 점점 더 복잡해지고 있어요',
    '저는 빠른 비트를 좋아해요',
    '최근 비트코인 가격이 많이 변동했어요',
]
df = pd.DataFrame(data, columns=['text'])

# 텍스트를 임베딩 벡터로 변환하는 함수
def get_embedding(text):
    return embeddings.embed_query(text)

df['embedding'] = df['text'].apply(get_embedding)

# 주어진 쿼리와 가장 유사한 상위 3개의 문서를 반환하는 함수
def return_answer_candidate(df, query):
    query_embedding = get_embedding(query)
    df["similarity"] = df.embedding.apply(
        lambda x: cos_sim(np.array(x), np.array(query_embedding))
    )
    return df.sort_values("similarity", ascending=False).head(3)

sim_result = return_answer_candidate(df, '과일 값이 비싸다')
print(sim_result[["text", "similarity"]])

"과일 값이 비싸다"라는 검색어를 넣으면, "시장"이나 "오르다"와 의미적으로 가까운 문장들이 상위로 검색된다 — 문장에 "과일"이라는 단어가 하나도 없는데도 의미가 통하는 문장을 찾아낸다는 점이 키워드 검색과 다른 임베딩 검색의 강점이다.

허깅페이스 임베딩 모델BAAI/bge-m3는 중국어를 주로 다루지만 한국어에도 좋은 성능을 보이는 다국어 임베딩 모델이고, nlpai-lab/KoE5는 한국어 텍스트 검색에 최적화된 모델이다. 비용 면에서는 오픈 모델이라 무료라는 장점이 있지만, 뒤에서 보듯 문맥이 복잡한 문장에서는 오픈AI 모델보다 정밀도가 낮을 수 있다.

from langchain_huggingface import HuggingFaceBgeEmbeddings

embeddings_bge = HuggingFaceBgeEmbeddings(model_name='BAAI/bge-m3')

참고 — bge-m3 모델은 "급등"·"가격"처럼 표면적으로 겹치는 키워드에는 강하지만, "시장 물가"처럼 문맥이 넓게 걸쳐 있는 표현은 상대적으로 덜 잡아낸다. 오픈AI 임베딩과 성능을 직접 비교한 결과는 부록 A에 정리했다. 용도와 예산에 따라 두 유형을 선택하거나 조합해서 쓰면 된다.

다음 절에서는 이렇게 임베딩할 원본 문서를 실제로 불러오는 문서 로더를 살펴본다.

2. 문서 로더

랭체인의 문서 로더는 웹 페이지·PDF·CSV 등 80개 이상의 다양한 데이터 소스를 표준화된 문서 객체(content+metadata)로 변환하는 도구다. 모든 로더가 같은 형태의 객체를 반환하므로, 소스가 무엇이든 뒤이은 텍스트 분할·임베딩·저장 단계는 동일한 코드로 처리할 수 있다. 대표적인 로더는 다음과 같다.

  • WebBaseLoader — 웹 페이지 내용을 로드
  • PyPDFLoader — PDF 파일을 페이지 단위로 로드·분할
  • CSVLoader — CSV 파일을 행 단위로 로드
  • DirectoryLoader — 지정한 디렉터리의 모든 파일을 로드
  • UnstructuredLoader — 다양한 형식의 파일을 로드

이 밖의 로더는 랭체인 문서 로더 통합 목록에서 확인할 수 있다(다만 이 목록은 서드파티·커뮤니티 통합 위주라 아래에서 다루는 WebBaseLoader·PyPDFLoader·CSVLoader 같은 핵심 로더 자체의 설명은 담고 있지 않다 — 이 장의 실습 코드로 직접 익히는 편이 더 정확하다). 이 장은 WebBaseLoader·PyPDFLoader·CSVLoader를 중점적으로 다룬다.

2.1 웹 페이지 로더

WebBaseLoader는 단일 또는 여러 웹 페이지에서 텍스트를 로드하며, 내부적으로 Beautiful Soup을 사용해 HTML을 파싱한다. 여러 페이지를 동시에 처리하는 비동기 로딩과, 필요한 페이지만 순차 처리하는 지연 로딩(lazy loading)을 지원해 성능을 높일 수 있다.

# 라이브러리 설치
!pip install langchain_community beautifulsoup4

import os
# 사용자 에이전트 설정 — 서버가 요청 주체를 식별할 수 있게 한다
os.environ["USER_AGENT"] = "MyApp/1.0 (Custom LangChain Application)"

from langchain_community.document_loaders import WebBaseLoader

# 단일 URL
loader = WebBaseLoader("https://docs.smith.langchain.com/")
# 다중 URL
loader_multiple_pages = WebBaseLoader([
    # 원문은 `python.langchain.com/docs/introduction/` 를 쓴다 — 그 사이트는 이사했고
    # 아래가 그 주소가 실제로 도착하는 현재 위치다(2026-09-12 확인).
    "https://docs.langchain.com/oss/python/langchain/overview",
    "https://langchain-ai.github.io/langgraph/",
])

single_doc = loader.load()
print(single_doc[0].metadata)

잘못된 예 / 올바른 예USER_AGENT 환경 변수를 설정하지 않고 WebBaseLoader를 바로 호출하면 요청 자체는 되지만 "USER_AGENT environment variable was not set" 경고가 뜬다. 서버가 어떤 클라이언트의 요청인지 식별하지 못하기 때문이다. 위 코드처럼 로더를 만들기 전에 USER_AGENT를 먼저 설정해 두면 경고 없이 조용히 동작한다.

단일 웹 페이지는 문자열로, 다중 웹 페이지는 리스트로 전달한다. 결과는 각 페이지의 메타데이터(출처·제목·언어)와 본문을 담은 문서 리스트로 반환되며, docs[0].page_content로 본문 텍스트에 접근할 수 있다.

2.2 PDF 로더

사용 빈도가 높은 PDF 로더 세 가지를 비교한다.

로더 특징
PyPDFLoader 간단하고 쓰기 쉬우나, 복잡한 레이아웃에서는 정확도가 떨어질 수 있음
PyMuPDFLoader PyPDFLoader보다 빠르고 정확하며, 이미지·표·레이아웃 정보까지 추출
PDFPlumberLoader 표 등 구조화된 데이터 추출에 특화. 상세 분석 때문에 속도는 가장 느림
from langchain_community.document_loaders import PyPDFLoader

# PDF 파일 로드 및 페이지 분할
loader = PyPDFLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/2024_KB_부동산_보고서_최종.pdf")
pages = loader.load_and_split()
print('청크의 수:', len(pages))

출력:

청크의 수: 83

원본 PDF는 84페이지이지만, 텍스트 없이 이미지만 있거나 완전히 빈 페이지는 로드 과정에서 제외되어 실제 청크 수는 84보다 적을 수 있다. load_and_split()은 각 페이지를 Document로 로드한 뒤 기본 분할기(RecursiveCharacterTextSplitter)로 다시 나누며, 반환되는 각 청크는 다음 형식을 갖는다.

Document(metadata={'source': 파일명, 'page': 페이지 번호}, page_content='본문 내용')

같은 파일을 PyMuPDFLoader로 불러오면 청크 수는 동일하게 83개지만, 실행 시간이 크게 줄어든다(측정 예시: PyPDFLoader 벽시계 기준 약 24초 vs PyMuPDFLoader 약 2.6초). PyMuPDFLoader는 파일 경로·페이지 수·문서 포맷·작성자 등 더 다양한 메타데이터도 함께 반환한다. PDFPlumberLoader는 이 둘보다 상세한 메타데이터를 제공하지만, 그만큼 처리 시간이 가장 길다(같은 파일 기준 약 39초). 세 로더 모두 청크 수 자체는 같게 나오므로, 선택 기준은 속도·메타데이터 상세도·표 추출 필요 여부다(부록 A에 비교표로 정리).

2.3 CSV 로더

CSV(Comma-Separated Values) 파일은 행과 열로 이루어진 텍스트 파일이다. 랭체인은 CSVLoaderUnstructuredCSVLoader 두 가지를 제공한다.

CSVLoader는 CSV를 행 단위로 로드해 각 행을 독립된 문서로 변환한다. 빈 행도 하나의 청크로 처리되므로, 1,000행짜리 CSV에 빈 행이 섞여 있어도 청크 수는 그대로 1,000개다.

from langchain_community.document_loaders import CSVLoader

loader = CSVLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/서울시_부동산_실거래가_정보.csv")
documents = loader.load()
print('청크의 수:', len(documents))

출력:

청크의 수: 2001

각 청크는 Document(metadata={'source': 파일명, 'row': 행 번호}, page_content='행 데이터') 형식을 갖는다. 반면 UnstructuredCSVLoaderunstructured 라이브러리를 사용해 CSV 전체를 하나의 문서로 로드(single 모드)하거나 요소별로 분리(elements 모드)할 수 있다.

from langchain_community.document_loaders import UnstructuredCSVLoader

loader = UnstructuredCSVLoader(
    "/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/서울시_부동산_실거래가_정보.csv",
    mode='elements',
)
documents = loader.load()
print('청크의 수:', len(documents))

출력:

청크의 수: 1

elements 모드에서는 전체 CSV가 비구조화된 텍스트 문서 하나로 로드되지만, 테이블의 HTML 표현이 메타데이터의 text_as_html 키에 함께 저장되어 원래 구조를 살펴볼 수 있다. 행 단위로 세밀하게 검색해야 한다면 CSVLoader가, CSV 전체 구조를 HTML로도 보존하고 싶다면 UnstructuredCSVLoader가 적합하다.

3. 텍스트 분할

언어 모델은 한 번에 처리할 수 있는 입력 길이에 한계가 있다. 문서 로더로 불러온 긴 문서를 적절한 크기로 나누는 것이 텍스트 분할기의 역할이다. 랭체인은 다양한 텍스트 분할기 통합 목록을 제공하며, 이 장은 그중 가장 널리 쓰이는 RecursiveCharacterTextSplitterSemanticChunker를 다룬다.

3.1 길이와 구분자로 분할하는 재귀적 문자 텍스트 분할

RecursiveCharacterTextSplitter는 긴 텍스트를 지정한 최대 길이(chunk_size)를 넘지 않는 청크로 반복해서 나눈다. 내부적으로 ["\n\n", "\n", " ", ""] 순서의 구분자를 적용하는데, 가장 큰 단위인 "\n\n"(문단)으로 먼저 나누고, 청크가 여전히 길면 "\n"(줄)로, 그래도 길면 공백으로 점점 더 작은 단위로 내려간다.

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/2024_KB_부동산_보고서_최종.pdf")
pages = loader.load()
print('총 글자 수:', len(''.join([p.page_content for p in pages])))

출력:

총 글자 수: 90699

90,699자는 언어 모델이 한 번에 처리하기엔 너무 길다. chunk_sizechunk_overlap 두 파라미터로 분할 방식을 정한다 — chunk_size는 각 청크의 최대 길이, chunk_overlap은 인접한 청크끼리 겹치게 할 문자 수다(문맥 연속성을 위해 보통 청크 크기의 10~20% 정도를 겹친다).

text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
texts = text_splitter.split_documents(pages)
print('분할된 청크의 수:', len(texts))
print('1번 청크의 길이:', len(texts[1].page_content))
print('2번 청크의 길이:', len(texts[2].page_content))

출력:

분할된 청크의 수: 242
1번 청크의 길이: 471
2번 청크의 길이: 442

chunk_overlap을 50으로 두었기 때문에 1번 청크의 끝부분과 2번 청크의 시작 부분이 일부 겹친다. 겹침 없이 자르고 싶다면 chunk_overlap=0으로 두면 된다.

잘못된 예 / 올바른 예chunk_size를 지나치게 크게 잡으면(예: 5000) 청크 하나에 여러 주제가 섞여 검색 정밀도가 떨어지고, 지나치게 작게 잡으면(예: 50) 문맥이 끊겨 챗봇이 앞뒤 맥락을 잃는다. 이 실습처럼 500~1000 사이에서 시작해 검색 결과를 보며 조정하는 편이 안전하다.

이 방식은 단순히 길이로만 자르기 때문에 빠르지만, 문맥을 고려하지 않아 내용이 중간에 끊기는 청크가 생길 수 있다. 문맥이 끊긴 청크가 챗봇에 입력되면 답변 정확도가 떨어질 수 있다는 것이 이 방식의 한계다.

3.2 의미 기반으로 분할하는 시맨틱 청킹

SemanticChunker는 텍스트를 길이가 아니라 의미적 유사성으로 분할한다. 문장을 임베딩 벡터로 바꾼 뒤, 인접한 문장 쌍 사이의 코사인 거리(1 - 코사인 유사도, 작을수록 비슷함)를 계산해 그 차이가 크게 벌어지는 지점 — 즉 주제가 바뀌는 지점 — 을 분할 지점으로 삼는다.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings
from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/2024_KB_부동산_보고서_최종.pdf")
pages = loader.load()

text_splitter = SemanticChunker(embeddings=OpenAIEmbeddings())
chunks = text_splitter.split_documents(pages)
print('분할된 청크의 수:', len(chunks))

출력:

분할된 청크의 수: 165

SemanticChunker는 분할 지점을 정하는 세 가지 방식을 제공하며, 모두 코사인 거리를 기준으로 삼는다.

방식 breakpoint_threshold_type 기준 예시 결과(같은 문서)
백분위수(기본값) "percentile" 코사인 거리 상위 5%(기본 amount=95) 지점에서 분할 165개
표준편차 "standard_deviation" 평균 + N×표준편차를 넘는 지점에서 분할 amount=3일 때 84개
사분위수 "interquartile" Q3 + N×IQR을 넘는 지점에서 분할 amount=1.5일 때 145개
# 표준편차 방식 예시
text_splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="standard_deviation",
    breakpoint_threshold_amount=3,
)
chunks = text_splitter.split_documents(pages)
print('분할된 청크의 수:', len(chunks))

출력:

분할된 청크의 수: 84

세 방식 모두 임계값을 낮추면 분할 지점이 늘어 더 짧은 청크가, 높이면 분할 지점이 줄어 더 긴 청크가 만들어진다. 백분위수 방식은 의미의 극단적 변화만 포착하고, 표준편차 방식은 전체 분포를, 사분위수 방식은 중간 50% 데이터의 분포를 기준으로 판단한다는 점이 다르다.

문서를 어떻게 분할하느냐는 RAG 챗봇의 성능에 직접 영향을 미친다 — 같은 임베딩 모델, 같은 벡터 DB를 쓰더라도 청크 경계가 문맥을 살리지 못하면 검색 품질이 떨어진다. 다음 절에서는 이렇게 분할한 청크를 실제로 저장하는 벡터 데이터베이스를 살펴본다.

4. 벡터 데이터베이스

앞서 1.3절에서는 판다스와 넘파이로 임베딩을 직접 저장·계산했지만, 실제 업무에서는 대량의 임베딩을 효율적으로 저장하고 빠르게 검색하기 위해 벡터 데이터베이스를 쓴다. 대표적인 벡터 데이터베이스와 특징은 다음과 같다.

벡터 데이터베이스 로컬 배포 클라우드 배포 비고
밀버스(Milvus) 가능 가능 유클리드·코사인·IP 등 다양한 메트릭
파이스(Faiss) 가능 불가(라이브러리) GPU 가속 고속 검색에 특화
크로마(Chroma) 가능 가능(자체 호스팅) 설정이 간단해 빠른 프로토타이핑에 적합
파인콘(Pinecone) 불가 가능(관리형) 완전 관리형 서비스
쿼드런트(Qdrant) 가능 가능 필터링에 강함
엘라스틱서치(Elasticsearch) 가능 가능 키워드 검색과 결합 용이
위비에이트(Weaviate) 가능 가능 하이브리드 검색 지원

이 책은 사용이 간편한 크로마파이스를 다룬다. 다른 벡터 데이터베이스와의 통합은 랭체인 벡터 저장소 통합 목록에서 확인할 수 있다.

4.1 크로마

크로마는 AI 애플리케이션을 위한 오픈소스 벡터 데이터베이스로, 실시간 검색과 낮은 지연 시간을 지원해 빠른 프로토타이핑에 적합하다. 다만 주로 단일 노드 환경에서 쓰이므로 대규모 데이터셋에는 한계가 있을 수 있다.

from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

loader = PyPDFLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/2024_KB_부동산_보고서_최종.pdf")
pages = loader.load()

text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(pages)
print("분할된 청크의 수:", len(splits))

출력:

분할된 청크의 수: 138
embedding_function = OpenAIEmbeddings()
persist_directory = "/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/directory/chroma"

vectordb = Chroma.from_documents(
    documents=splits,
    embedding=embedding_function,
    persist_directory=persist_directory,  # 디스크에 영구 저장할 경로
)
print('문서의 수:', vectordb._collection.count())

출력:

문서의 수: 138

잘못된 예 / 올바른 예persist_directory를 지정하지 않으면 크로마는 데이터를 메모리에만 저장하므로, 프로그램(세션)이 끝나는 순간 데이터가 사라진다. 이 코드처럼 경로를 지정해야 chroma.sqlite3 파일이 만들어져 코드 실행이 끝나도 데이터베이스가 남는다. 저장된 데이터베이스는 Chroma(persist_directory=경로, embedding_function=임베딩)으로 다시 불러올 수 있다.

이렇게 저장한 데이터베이스에서 similarity_search()로 질문과 유사한 문서를 찾을 수 있다.

question = "수도권 주택 매매 전망"
top_docs = vectordb.similarity_search(question, k=2)
for i, doc in enumerate(top_docs, 1):
    print(f"문서 {i}:")
    print(f"내용: {doc.page_content[:150]}...")
    print(f"메타데이터: {doc.metadata}")

k 인자로 검색할 문서 개수를 조절하며(기본값 4), similarity_search_with_relevance_scores()를 쓰면 유사도 점수까지 함께 확인할 수 있다.

주의Chroma.from_documents()를 반복 호출하면 같은 문서가 중복으로 쌓일 수 있다. 이미 저장된 데이터베이스를 다시 쓸 때는 from_documents() 대신 Chroma(persist_directory=..., embedding_function=...)로 불러오는 쪽을 쓴다.

4.2 파이스

파이스는 메타 AI 리서치가 개발한 고성능 벡터 검색 라이브러리로, GPU 가속을 통해 수백만 개 이상의 고차원 벡터도 빠르게 검색하도록 설계됐다. 이미지 검색·추천 시스템·대규모 문서 검색에 널리 쓰이지만, 고급 기능을 쓰려면 설정이 복잡해지고 분산 환경에서는 제약이 있을 수 있다.

from langchain_community.vectorstores import FAISS

faiss_db = FAISS.from_documents(documents=splits, embedding=embedding_function)
print('문서의 수:', faiss_db.index.ntotal)

출력:

문서의 수: 138

파이스는 크로마와 달리 저장된 문서 수를 faiss_db.index.ntotal로 확인하고, 저장·로드 방식도 다르다.

faiss_directory = '/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/directory/faiss_index'

# 저장
faiss_db.save_local(faiss_directory)

# 불러오기 — 저장에 쓴 것과 같은 임베딩 모델을 함께 전달해야 한다
new_db_faiss = FAISS.load_local(
    faiss_directory,
    OpenAIEmbeddings(),
    allow_dangerous_deserialization=True,
)

잘못된 예 / 올바른 예allow_dangerous_deserialization=True는 파이썬 객체 역직렬화 과정의 보안 경고를 무시하는 옵션이다. 출처를 알 수 없는 파이스 인덱스 파일에 이 옵션을 켜고 불러오면 임의 코드 실행 위험을 그대로 안게 된다. 이 예제처럼 자신이 방금 저장한 파일임을 확신할 수 있을 때만 켜야 하며, 다른 사람이 배포한 인덱스 파일을 그대로 받아 이 옵션으로 로드하는 것은 피해야 한다.

같은 방식으로 similarity_search()를 호출하면 크로마와 동일한 결과를 얻을 수 있다. 최종적으로 벡터 DB를 챗봇에서 쓸 때는 as_retriever()검색기를 만들어 활용한다 — 다음 절에서 실습과 함께 살펴본다.

5. RAG 챗봇 구현

지금까지 임베딩·문서 로더·텍스트 분할·벡터 데이터베이스를 각각 살펴봤다. 이 절에서는 이 요소들을 이어 붙여 하나의 RAG 챗봇을 완성한다.

인덱싱 과정에서는 문서를 청크로 분할해 벡터 데이터베이스에 저장한다. 이 준비는 챗봇 실행 전에 미리 끝내 둔다. 쿼리 과정에서는 사용자 질문이 들어오면 ① 질문을 벡터로 변환하고 ② 검색기로 관련성 높은 상위 문서(예: 상위 3개)를 찾고 ③ 검색된 문서와 질문을 함께 프롬프트로 구성해 ④ 언어 모델에 전달해 최종 답변을 생성한다.

5.1 RAG 챗봇 구현

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain.schema.output_parser import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableWithMessageHistory
from langchain.memory import ChatMessageHistory

# ── 인덱싱 과정 ──
loader = PyPDFLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/2024_KB_부동산_보고서_최종.pdf")
documents = loader.load()

text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = text_splitter.split_documents(documents)
print('분할된 청크의 수:', len(chunks))

embedding_function = OpenAIEmbeddings()
persist_directory = "/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/directory/chroma"
vectorstore = Chroma.from_documents(
    documents=chunks, embedding=embedding_function, persist_directory=persist_directory,
)
print('문서의 수:', vectorstore._collection.count())

출력:

분할된 청크의 수: 138
문서의 수: 138
# ── 쿼리 과정 ──
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})  # 상위 3개 검색

template = """당신은 KB 부동산 보고서 전문가입니다. 다음 정보를 바탕으로 사용자의 질문에 답변해주세요.
컨텍스트: {context}"""

prompt = ChatPromptTemplate.from_messages([
    ("system", template),
    ("placeholder", "{chat_history}"),
    ("human", "{question}"),
])
model = ChatOpenAI(model_name="gpt-4o-mini", temperature=0)

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# 체인 구성 — 검색된 문서를 프롬프트에 연결하고 모델을 거쳐 문자열로 반환
chain = (
    RunnablePassthrough.assign(
        context=lambda x: format_docs(retriever.invoke(x["question"]))
    )
    | prompt
    | model
    | StrOutputParser()
)

RunnablePassthrough.assign()은 입력값(question)을 그대로 유지하면서 context 키를 새로 추가한다 — 검색기가 찾은 문서를 format_docs()로 하나의 문자열로 합쳐 context에 채워 넣는 방식이다. 이렇게 만든 체인은 러너블(LCEL)| 연산자로 프롬프트 → 모델 → 출력 파서를 순서대로 이어 붙인 것이다.

# 대화 기록을 기억하는 챗봇으로 확장
chat_history = ChatMessageHistory()
chain_with_memory = RunnableWithMessageHistory(
    chain,
    lambda session_id: chat_history,
    input_messages_key="question",
    history_messages_key="chat_history",
)

# 챗봇 실행 함수
def chat_with_bot():
    session_id = "user_session"
    print("KB 부동산 보고서 챗봇입니다. 질문해 주세요. (종료하려면 'quit' 입력)")
    while True:
        user_input = input("사용자: ")
        if user_input.lower() == 'quit':
            break
        response = chain_with_memory.invoke(
            {"question": user_input},
            {"configurable": {"session_id": session_id}},
        )
        print("챗봇:", response)

실행 예시 — 대화 기록 덕분에 챗봇이 "방금 제가 뭐라고 이야기했나요?" 같은 후속 질문에도 앞선 발화를 그대로 기억해 답한다.

사용자: 랭체인 챗봇 만들기에 대해 실습중입니다.
챗봇: 랭체인 챗봇 만들기는 흥미로운 주제입니다! ...
사용자: 방금 제가 뭐라고 이야기했나요?
챗봇: 귀하가 방금 말씀하신 내용은 "랭체인 챗봇 만들기에 대해 실습중입니다." 였습니다.
사용자: 노후계획도시가 어디인지 궁금해
챗봇: 노후계획도시는 1기 신도시로 불리는 지역들을 가리키는 용어입니다. 성남시 분당, 고양시 일산, 부천시 중동, 안양시 평촌, 군포시 산본의 5개 지역입니다.

챗봇은 검색된 문서(부동산 보고서 원문)에 근거해 답변하므로, 모델이 학습 시점에 몰랐을 "노후계획도시"라는 국내 특정 정책 용어도 정확히 답할 수 있다 — 이것이 RAG의 핵심 가치다.

5.2 챗봇에 스트림릿 UI 적용

스트림릿을 쓰면 순수 파이썬 코드만으로 대화형 웹 UI를 만들 수 있다. 구글 코랩에서 실습할 때는 로컬 서버를 외부에 여는 파이엔그록도 함께 설치한다.

!pip install streamlit pyngrok

app.py 파일에 챗봇 로직과 UI를 함께 작성한다.

# app.py
import os
import streamlit as st
from dotenv import load_dotenv
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain.schema.output_parser import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableWithMessageHistory
from langchain.memory import ChatMessageHistory

load_dotenv("/content/.env")
api_key = os.getenv("OPENAI_API_KEY")

@st.cache_resource
def process_pdf():
    loader = PyPDFLoader("/content/drive/MyDrive/langchain-tutorial/Ch02.RAG/Data/2024_KB_부동산_보고서_최종.pdf")
    documents = loader.load()
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    return text_splitter.split_documents(documents)

@st.cache_resource
def initialize_vectorstore():
    chunks = process_pdf()
    embeddings = OpenAIEmbeddings(openai_api_key=api_key)
    return Chroma.from_documents(chunks, embeddings)

@st.cache_resource
def initialize_chain():
    vectorstore = initialize_vectorstore()
    retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
    template = """당신은 KB 부동산 보고서 전문가입니다. 다음 정보를 바탕으로 사용자의 질문에 답변해주세요.
컨텍스트: {context}"""
    prompt = ChatPromptTemplate.from_messages([
        ("system", template),
        ("placeholder", "{chat_history}"),
        ("human", "{question}"),
    ])
    model = ChatOpenAI(model_name="gpt-4o-mini", temperature=0, openai_api_key=api_key)

    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    base_chain = (
        RunnablePassthrough.assign(context=lambda x: format_docs(retriever.invoke(x["question"])))
        | prompt | model | StrOutputParser()
    )
    return RunnableWithMessageHistory(
        base_chain, lambda session_id: ChatMessageHistory(),
        input_messages_key="question", history_messages_key="chat_history",
    )

def main():
    st.set_page_config(page_title="KB 부동산 보고서 챗봇")
    st.title("KB 부동산 보고서 AI 어드바이저")
    st.caption("2024 KB 부동산 보고서 기반 질의응답 시스템")

    if "messages" not in st.session_state:
        st.session_state.messages = []

    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

    if prompt := st.chat_input("부동산 관련 질문을 입력하세요"):
        with st.chat_message("user"):
            st.markdown(prompt)
        st.session_state.messages.append({"role": "user", "content": prompt})

        chain = initialize_chain()
        with st.chat_message("assistant"):
            with st.spinner("답변 생성 중..."):
                response = chain.invoke(
                    {"question": prompt},
                    {"configurable": {"session_id": "streamlit_session"}},
                )
            st.markdown(response)
        st.session_state.messages.append({"role": "assistant", "content": response})

if __name__ == "__main__":
    main()

@st.cache_resource는 PDF 로드·벡터스토어 생성처럼 비용이 큰 연산의 결과를 캐싱해, 페이지가 새로고침돼도 매번 다시 계산하지 않게 한다. st.session_state는 대화 기록을 저장해 새로고침 후에도 화면에 이전 대화가 남아 있게 하고, st.chat_message·st.chat_input·st.spinner는 각각 메시지 말풍선·입력창·응답 생성 중 표시를 담당한다. 흐름을 정리하면 다음과 같다.

  1. process_pdf — PDF를 로드하고 청크로 분할한다.
  2. initialize_vectorstore — 청크를 임베딩해 크로마에 저장한다.
  3. initialize_chain — 검색기와 프롬프트를 엮어 체인을 만든다.
  4. main — 스트림릿 UI를 구성하고 사용자 입력마다 체인을 호출해 답변을 표시한다.

앱을 실행하고 외부에서 접근 가능한 URL을 만들려면 다음을 실행한다.

from pyngrok import ngrok

public_url = ngrok.connect(8501)  # 스트림릿 기본 포트
print("앱 접속 URL:", public_url)
!streamlit run /content/app.py

ngrok.connect(8501)은 스트림릿의 기본 포트(8501)를 https://[무작위주소].ngrok-free.app 형식의 공개 URL로 바꿔 준다. ngrok을 처음 쓴다면 ngrok 공식 사이트에서 회원 가입 후 대시보드에서 인증키(Authtoken)를 발급받아 !ngrok config add-authtoken <인증키>로 등록해야 한다. 생성된 URL에 접속하면 먼저 ngrok 보안 페이지가 뜨고, [Visit Site]를 누르면 스트림릿 앱이 로드된다. 화면 상단에는 제목과 부제가 표시되고, 입력창에 질문을 입력하면 위에서 만든 체인이 문맥을 검색해 답변을 생성해 보여 준다. 이렇게 완성한 RAG 챗봇은 이후 4장(검색 고도화)·5장(그래프 RAG)에서 검색 품질을 높이는 다양한 기법을 얹는 기반이 된다.

핵심 개념 정리

개념 한 줄 설명
임베딩 텍스트를 의미를 담은 실수 벡터로 바꾸는 변환
코사인 유사도 두 벡터의 방향 차이로 유사성을 재는 척도(-1~1)
문서 로더 웹·PDF·CSV 등을 content+metadata 표준 문서로 변환하는 도구
재귀적 문자 텍스트 분할 구분자 순서(\n\n\n)로 길이 기준에 맞춰 반복 분할
시맨틱 청킹 코사인 거리(의미 차이)가 크게 벌어지는 지점에서 분할
벡터 데이터베이스 임베딩을 저장하고 유사도 검색을 빠르게 수행하는 저장소
크로마 persist_directory로 영구 저장이 가능한 오픈소스 벡터 DB
파이스 GPU 가속 대규모 유사도 검색에 특화된 라이브러리
검색기(Retriever) 벡터 저장소에서 질문과 유사한 문서를 찾아 반환하는 컴포넌트
RAG 챗봇 체인 검색기 → 프롬프트 → 모델 → 출력 파서를 LCEL로 이은 구조

실무 체크리스트

  • [ ] WebBaseLoader를 쓰기 전에 USER_AGENT 환경 변수를 설정했는가?
  • [ ] PDF를 대량으로 처리할 때 속도가 문제라면 PyMuPDFLoader로 바꿔 보았는가?
  • [ ] chunk_size를 정할 때 문서 성격(짧은 공지문 vs 긴 보고서)에 맞춰 값을 조정했는가?
  • [ ] chunk_overlap을 0으로 두어도 문맥이 끊기지 않는지 확인했는가?
  • [ ] 크로마를 쓸 때 persist_directory를 지정해 세션 종료 후에도 데이터가 남게 했는가?
  • [ ] 같은 문서를 여러 번 Chroma.from_documents()로 저장해 중복이 쌓이지 않았는가?
  • [ ] 파이스 인덱스를 불러올 때 allow_dangerous_deserialization=True를 신뢰할 수 있는 파일에만 썼는가?
  • [ ] as_retriever(search_kwargs={"k": N})k 값을 문서 길이와 질문 성격에 맞게 정했는가?
  • [ ] 챗봇에 대화 기록이 필요한 서비스라면 RunnableWithMessageHistory로 세션별 기록을 관리했는가?

연습문제

  1. 개념. 텍스트 임베딩과 코사인 유사도가 각각 무엇을 계산하는지 설명하고, 둘이 어떻게 함께 쓰여 "의미가 비슷한 문서 찾기"를 가능하게 하는지 말하라.
  2. 적용. 사내 공지사항(A4 한 장 분량)과 200쪽짜리 정책 보고서(B)를 각각 벡터 DB에 저장하려 한다. chunk_size·chunk_overlap을 문서 A와 B에 각각 어떻게 다르게 설정할지, 근거를 들어 제안하라.
  3. 비교. RecursiveCharacterTextSplitterSemanticChunker의 분할 기준 차이를 설명하고, 법률 계약서처럼 문단 경계가 명확한 문서와 자유 형식 인터뷰 녹취록처럼 주제가 자주 바뀌는 문서에 각각 어느 쪽이 더 적합한지 근거를 들어 논하라.
  4. 설계. 사내 문서 100만 건을 다루는 프로덕션 검색 시스템과, 개인 프로젝트로 PDF 한 권을 검색하는 프로토타입을 각각 만든다고 하자. 크로마와 파이스 중 어느 쪽을 선택할지 각 상황에 대해 근거를 들어 제안하라.
  5. 분석. 5.1절의 RAG 챗봇이 "노후계획도시가 어디인지 궁금해"라는 질문에 정확히 답할 수 있었던 이유를, 이 장에서 다룬 인덱싱 과정과 쿼리 과정의 각 단계를 근거로 설명하라.

최신 동향 (2026-09 기준)

최신 동향 (검증 2026-09-12) — 이 장이 다룬 임베딩·유사도·문서 로더·텍스트 분할·벡터 DB의 원리는 그대로 유효하다. 다만 이 장의 코드가 쓴 임포트 경로와 임베딩 모델 선택 두 가지는 책 집필 이후 달라졌다.

  • langchain.embeddings.OpenAIEmbeddings 임포트(§1.3의 원문 코드가 사용)는 더 이상 권장되지 않는다. 랭체인은 이 클래스를 langchain-community 0.0.9부터 지원 중단(deprecated) 예정으로 표시했고, 공식 문서는 langchain_openai.OpenAIEmbeddings로 옮겨 쓰라고 안내한다 — 이 장의 예제 코드는 처음부터 from langchain_openai import OpenAIEmbeddings로 고쳐 실었다. (LangChain 공식 임베딩 통합 문서)
  • text-embedding-ada-002는 이제 레거시 모델이다. 오픈AI는 이 모델을 당장 없애지는 않는다고 밝혔지만, 다국어 검색·영어 작업 벤치마크 모두에서 더 우수하고 5배 저렴한 text-embedding-3-small과, 더 높은 정확도가 필요할 때 쓰는 text-embedding-3-large를 새 프로젝트의 기본값으로 권장한다. 이 장 §1.3의 실습 자체는 어떤 임베딩 모델을 넣어도 그대로 동작하므로, 새로 시작하는 프로젝트라면 model="text-embedding-3-small"로 바꿔 실습해도 좋다. (OpenAI 공식 — New embedding models and API updates)

부록 A. 핵심 비교표

재귀적 문자 텍스트 분할 vs 시맨틱 청킹

구분 재귀적 문자 텍스트 분할 시맨틱 청킹
분할 기준 글자 수·구분자(길이) 문장 간 코사인 거리(의미)
속도 빠름 — 임베딩 계산 불필요 느림 — 문장마다 임베딩 필요
문맥 보존 경계에서 문맥이 끊길 수 있음 주제 전환 지점에서 자연스럽게 분리
적합한 문서 구조가 규칙적인 문서(보고서·매뉴얼) 주제가 자주 바뀌는 자유 형식 텍스트

크로마 vs 파이스

구분 크로마 파이스
성격 오픈소스 벡터 데이터베이스 벡터 검색 라이브러리(DB 기능은 최소)
영구 저장 persist_directory 지정만으로 가능 save_local/load_local로 수동 저장
대규모 처리 단일 노드 환경에서 한계 있음 GPU 가속으로 대규모 데이터셋에 강함
적합한 상황 빠른 프로토타이핑, 중소 규모 데이터 수백만 벡터 이상의 고속 유사도 검색

LLM 제공사 임베딩 모델 vs 허깅페이스 임베딩 모델

구분 LLM 제공사 임베딩(오픈AI 등) 허깅페이스 임베딩(bge-m3 등)
비용 API 호출당 과금 오픈 모델은 무료(자체 호스팅 비용은 별도)
성능 경향 복잡한 문맥에서도 안정적 표면적 키워드 일치에는 강하나 넓은 문맥 파악은 상대적으로 약함
실행 환경 인터넷 연결과 API 키 필요 로컬 실행 가능(오프라인·데이터 보안에 유리)

PyPDFLoader vs PyMuPDFLoader vs PDFPlumberLoader

구분 PyPDFLoader PyMuPDFLoader PDFPlumberLoader
속도 보통 가장 빠름 가장 느림
메타데이터 기본 수준 상세(포맷·작성자 등) 가장 상세
강점 사용이 간단함 속도와 메타데이터의 균형 표 등 구조화된 데이터 추출

부록 B. 추천 참고 자료

외부 자료 (Tier 1 공식, 생존 확인 2026-09-12 — 원문이 인쇄한 몇몇 주소는 랭체인 문서 개편으로 이전되었고, 실제로 이동한 현재 주소로 교체했다)

  • 랭체인 텍스트 임베딩 통합 목록 — §1.3이 다룬 오픈AI 외 임베딩 모델을 더 찾아볼 때. Embedding model integrations
  • 랭체인 문서 로더 통합 목록 — §2가 다룬 세 로더 외의 서드파티·커뮤니티 로더 전체 목록(단 WebBaseLoader·PyPDFLoader·CSVLoader 같은 핵심 로더 자체 설명은 이 목록에 없다). Document loader integrations
  • 랭체인 텍스트 분할기 통합 목록 — §3의 RecursiveCharacterTextSplitter 동작 원리와 다른 분할기들. Text splitter integrations
  • 랭체인 벡터 저장소 통합 목록 — §4의 크로마·파이스 외에 파인콘·쿼드런트 등 다른 벡터 DB 연동법. Vector store integrations
  • 파이스 공식 GitHub — §4.2의 내부 구현과 인덱스 종류(HNSW 등)를 더 깊이 볼 때. facebookresearch/faiss
  • BAAI/bge-m3 모델 카드 — §1.3에서 쓴 다국어 임베딩 모델의 구조와 성능 벤치마크. huggingface.co/BAAI/bge-m3
  • 크로마 공식 문서 — §4.1의 persist_directory·컬렉션 API를 더 자세히. docs.trychroma.com
  • 오픈AI 임베딩 가이드 — §1.3의 임베딩 API 파라미터·차원 축소(dimensions) 옵션. OpenAI — Vector embeddings
  • 스트림릿 커뮤니티 클라우드 퀵스타트 — §5.2에서 만든 앱을 로컬 ngrok 대신 정식 배포로 옮길 때. Streamlit Community Cloud quickstart

본 책 연계 챕터

챕터 이 장이 다루지 않은 것
3장 §2 멀티모달 RAG 구현 방법 — 세 가지 접근 이 장은 텍스트만 다뤘다. 이미지·표 같은 다른 모달리티를 같은 벡터 공간에 넣거나 별도 저장소로 다루는 방법
4장 §1 청킹 전략 이 장의 재귀적 문자 분할·시맨틱 청킹을 넘어서는 부모-자식 분할 등 검색 성능을 더 끌어올리는 청킹 기법
4장 §3 검색 알고리즘 이 장의 as_retriever() 기본 유사도 검색을 희소·밀집·앙상블 검색으로 고도화하는 방법
5장 §1 그래프 RAG 개요 이 장이 쓴 벡터 유사도 검색과는 다른 방식 — 지식을 그래프로 표현하고 질의하는 접근
8장 §3 RAG 학습 데이터셋 살펴보기 이 장에서 만든 RAG 파이프라인을 바탕으로, RAFT 방식의 파인튜닝용 학습 데이터셋을 구성하는 법

부록 C. 연습문제 풀이

  1. (임베딩과 코사인 유사도) 임베딩은 텍스트(단어·문장·문서)를 의미를 담은 실수 벡터로 바꾸는 과정이고, 코사인 유사도는 그렇게 만들어진 두 벡터 사이의 각도를 계산해 방향이 얼마나 비슷한지를 -1~1 값으로 나타내는 척도다. 문서와 질문을 같은 임베딩 모델로 벡터화한 뒤 코사인 유사도를 계산하면, 표면적 키워드가 겹치지 않아도 의미가 가까운 문서를 찾아낼 수 있다 — "과일 값이 비싸다"라는 질문이 "시장"·"오르다" 관련 문장을 찾아낸 것이 그 예다.

  2. (청크 크기 설계) A4 한 장짜리 공지사항(A)은 문서 전체가 이미 하나의 완결된 문맥이므로 chunk_size를 문서 길이에 가깝게(예: 1000~1500) 잡아 굳이 여러 청크로 쪼개지 않는 편이 낫고, chunk_overlap도 0에 가깝게 둬도 무방하다. 200쪽짜리 정책 보고서(B)는 주제별로 절이 나뉘어 있으므로 chunk_size를 500~1000 사이로 잡아 각 청크가 하나의 소주제를 담게 하고, 문맥 연속성을 위해 chunk_overlap을 청크 크기의 10~20%(예: 100~200) 정도로 설정하는 편이 검색 정밀도와 문맥 보존의 균형을 맞춘다.

  3. (분할기 선택) RecursiveCharacterTextSplitter는 글자 수와 구분자(문단→줄→공백) 순서로 기계적으로 나누므로 빠르지만 의미 경계를 보장하지 않고, SemanticChunker는 문장 간 코사인 거리를 계산해 주제가 바뀌는 지점에서 나누므로 느리지만 문맥을 더 잘 보존한다. 법률 계약서처럼 조항·문단 구분이 이미 명확한 문서는 굳이 임베딩 비용을 들이지 않고도 RecursiveCharacterTextSplitter"\n\n" 구분자만으로 조항 경계를 거의 그대로 살릴 수 있어 효율적이다. 반대로 인터뷰 녹취록처럼 문단 구분 없이 주제가 자주 바뀌는 텍스트는 길이 기준 분할이 주제 중간을 잘라 버리기 쉬우므로, 비용이 더 들더라도 SemanticChunker로 의미 전환 지점을 찾아 나누는 편이 검색 품질에 유리하다.

  4. (벡터 DB 선택) 사내 문서 100만 건 규모의 프로덕션 검색 시스템은 대규모 데이터셋에서 GPU 가속으로 빠른 유사도 검색을 제공하는 파이스가 적합하다 — 다만 파이스는 DB라기보다 라이브러리에 가까우므로 저장·백업·운영 체계를 직접 구성해야 한다. 개인 프로젝트로 PDF 한 권을 검색하는 프로토타입은 데이터 규모가 작고 빠르게 실험해 봐야 하므로, 설정이 간단하고 persist_directory 지정만으로 영구 저장이 되는 크로마가 더 적합하다.

  5. (RAG의 답변 근거) 인덱싱 과정에서 KB 부동산 보고서를 청크로 분할해 임베딩한 뒤 크로마에 저장해 두었기 때문에, "노후계획도시"라는 표현이 담긴 청크가 벡터 공간에 이미 존재했다. 쿼리 과정에서 사용자 질문이 들어오자 같은 임베딩 모델로 질문을 벡터화하고, 검색기가 코사인 유사도로 그 청크를 상위 3개 안에 찾아냈다. 이 청크들이 format_docs()로 합쳐져 프롬프트의 context에 채워졌고, 모델은 자신의 사전 지식이 아니라 이 문맥을 근거로 정확한 지역명을 답할 수 있었다 — 모델이 몰랐던 국내 특정 용어라도, 검색된 원문 문맥이 있으면 정확히 답할 수 있다는 것이 RAG의 핵심이다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플   P 음성 버전 2026-08-05b